Skip to content

feat: add scoped subject-turn submission - #468

Open
neubig wants to merge 6 commits into
factory/agent-turn-runsfrom
factory/subject-turns
Open

neubig wants to merge 6 commits into
factory/agent-turn-runsfrom
factory/subject-turns

Conversation

@neubig

@neubig neubig commented Sep 14, 2026

Copy link
Copy Markdown
Member

Why

A run-scoped scanner can identify work, but it needs a general, least-privilege way to ask Automation to create or resume conversation-scoped work for a stable external subject. Extensions should select work and submit a turn; they should not duplicate conversation attachment, runtime credentials, admission, or local/Docker lifecycle logic.

The operation must also recover when an earlier attempt never completes. Permanently deduplicating failed, canceled, skipped, or timed-out work can leave an external issue or PR stuck forever.

Summary

  • Add scoped POST /v1/runs/{run_id}/subject-turns.
  • Accept a source, opaque subject key, prompt, and idempotency key from the currently running scanner.
  • Reuse subject locking, coalescing, deterministic conversation identity, and the conversation-scoped child runs from feat: run scanner-selected conversation turns #467.
  • Deduplicate queued, running, successful, and not-yet-released work.
  • Retry failed, canceled, skipped, or timed-out work in the same deterministic conversation after its runtime is released. A child that fails before acquiring a runtime releases its subject immediately.
  • Release a terminal failed subject even when runtime cleanup reports that the runtime is already unavailable, so the same idempotent request can retry instead of remaining permanently blocked.
  • Release conversation subjects when watchdog verification records a failed or timed-out run, keeping the same retry guarantee after dispatcher restarts or crashes.
  • Give every turn submitted after a subject run reaches terminal state a new tracked child run in the same conversation, so watchdog polling and runtime cleanup cover resumed work too.
  • Give the scanner a short-lived capability token limited to subject submission for its own run and automation. It receives neither conversation credentials nor control over conversation IDs.

Issue Number

Closes #463. Closes #470.

How to Test

  • Full assembled stack: 1,805 passed, 7 skipped.
  • Subject-turn coverage verifies authorization, profile requirements, idempotency, source isolation, queued coalescing, continuation, retryable terminal outcomes, pre-runtime dispatch failure, and unreleased-runtime protection. Focused dispatcher coverage also verifies retry release when both turn submission and runtime cleanup fail. All 116 conversation, subject-turn, and watchdog tests pass, including failed-command and unavailable-runtime subject release and tracked restart after terminal state.
  • All pre-commit checks pass, including Ruff, pycodestyle, and Pyright.

Live Agent Canvas evidence

The four UI-installed GitHub extensions used this endpoint from host-side scanners while only selected agents ran in Docker. For airbnb-clone #63, the triager, developer, and reviewer each submitted work under their own stable source and subject. The reviewer posted a readable assessment on PR #72, published exact-head review and test success statuses, and the watchdog merged it automatically.

Earlier persisted Canvas runs also demonstrated retrying paused and timed-out subjects into their deterministic conversations and admitting two agents at the configured concurrency limit.

Dependencies and review order

Native stack #454: #449#453#466#467#468. Review and merge in that order.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Coverage

Warning

Your comment is too long (maximum is 65536 characters), so the coverage report was not added. See the job log for how to reduce it.

@neubig

neubig commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

@all-hands-bot Please review the current head and explicitly approve it if there are no blocking findings.

@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: cc761566516e208d38cc9782b5f4dc2edf0a4862
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/38a864be-e389-4098-ba6e-3a1e7c4caea5

This comment was posted by an AI agent (OpenHands).

@all-hands-bot

Copy link
Copy Markdown
Contributor

Review of cc76156 — feat: add scoped subject-turn submission

I reviewed the full diff against the merge base (c985c66, factory/agent-turn-runs) and ran Ruff lint + format checks on all changed files. The test suite requires Docker/PostgreSQL via testcontainers, which is not available in this environment, so I could not execute the 11 subject-turn tests locally — but I verified every code path statically against the existing models, helpers, and config.

What the PR does

Adds POST /v1/runs/{run_id}/subject-turns — a scoped, idempotent endpoint that lets a running automation script fan out agent work for external subjects without ever receiving conversation credentials or managing runtime lifecycle.

Findings by area

Security — run token (utils/run_token.py, subject_router.py)

  • HS256 JWT signed with the deployment service_key (falling back to local_api_key), carrying automation_id, run_id, scopes, iat, and exp (24h).
  • verify_run_token checks signature, expiration, and required scope. The endpoint then enforces that the token's run_id matches the path, automation_id matches the loaded run's automation, and the requester run is RUNNING.
  • The dispatcher injects AUTOMATION_RUN_TOKEN + AUTOMATION_SUBJECT_TURN_URL only when a signing secret is configured; otherwise it silently skips (graceful degradation). No blocking issues.

Idempotency model (models.py, migration 028)

  • AutomationSubjectTurn table with a UNIQUE(automation_id, source, subject_key, idempotency_key) constraint and CASCADE foreign keys to automations and automation_runs.
  • Migration 028 (revision 028, down_revision 027) matches the ORM definition exactly. The chain 026 → 027 → 028 is intact.

Core logic — submit_subject_turn (conversations.py)

  • Subject lock (pg_advisory_xact_lock) is taken before the idempotency check, so a retry cannot enqueue two conversations.
  • Dedup path: if the idempotency record exists and the prior run is still active or not yet released → deduplicated.
  • Retry path: if the prior run is FAILED/CANCELLED/SKIPPED and has released its runtime (or never started) → a new attempt is created in the same deterministic conversation, and the single idempotency record is repointed to the new run. The superseded run stays in history.
  • The never-started case (started_at is None) correctly sets subject_released_at so the old run is excluded from _lock_subject_run's lookup.
  • compose_turn receives event_payload=None with override=turn, so the caller's prompt is used verbatim — confirmed by reading conversation_turn.py:159.
  • The guard if subject_run is not None and subject_run.status not in _FINISHED: raise RuntimeError correctly maps to 503 for an unreachable conversation.

Endpoint (subject_router.py)

  • 401 for missing/invalid token, 403 for token belonging to another run or automation, 409 when the requester is not RUNNING, 422 for invalid subject key, 503 for runtime unavailability, 202 on success.
  • Session lifecycle is handled by Depends(get_session). Correct.

Dispatcher injection (dispatcher.py)

  • AUTOMATION_SUBJECT_TURN_URL = {resolved_base_url}/v1/runs/{run_id}/subject-turns, and resolved_base_url = {base}/api/automation. The router is mounted at _base_path (/api/automation) with prefix /v1/runs. The paths match.

Schema validation (schemas.py)

  • SubjectTurnRequest uses extra="forbid", enforces length bounds, strips whitespace, and rejects empty strings. SubjectTurnResponse returns disposition, run_id, conversation_id.

Test coverage (test_subject_turns.py)

  • 11 tests covering: first-turn creation, same-key dedup, retry after released failure (parametrized), retry of never-started skipped run, wait-for-release, source isolation, endpoint auth (403/202), and continuation without a second run. Good coverage of the critical paths.

Lint/format: ruff check and ruff format --check pass on all 8 changed files.

Verdict

No blocking findings. Approving.

Generated by OpenHands AI on behalf of the user.

all-hands-bot
all-hands-bot previously approved these changes Sep 15, 2026

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR adds a scoped POST /v1/runs/{run_id}/subject-turns endpoint that lets a running automation script submit work for an external subject (issue, PR, ticket) without receiving conversation credentials or managing runtime lifecycle. The service owns conversation identity, profile selection, and runtime attachment. The implementation reuses existing subject locking, coalescing, deterministic conversation IDs, and the agent-turn run machinery from #467.

Analysis

Security model is sound. The run token is a short-lived (24h) HS256 JWT scoped to subject_turn:submit and bound to a specific automation_id + run_id. The router validates the bearer token, checks scope, verifies run_id and automation_id match the URL path and DB record, and requires the requester run to be RUNNING. The script inside the sandbox cannot forge tokens for other runs or automations, and cannot choose an arbitrary conversation ID.

Idempotency and retry logic is well-designed. The unique constraint on (automation_id, source, subject_key, idempotency_key) prevents duplicates at the DB level. The transaction-scoped advisory lock (pg_advisory_xact_lock) serializes concurrent submissions for the same subject, so the SELECT-then-INSERT idempotency check is race-free. The retry path correctly releases failed/skipped runs from the subject lookup (subject_released_at) before creating a replacement, and reuses the deterministic conversation ID so the new attempt continues the same conversation. The single idempotency record is repointed to the new attempt while the superseded run remains in history.

Migration is cross-database compatible. Uses generic sa.Uuid, sa.String, sa.DateTime types. The unique constraint creates an implicit index that covers the idempotency lookup query. The test_migrations_run_on_sqlite test is updated.

Test coverage is strong. 9 test functions (11 with parameterization) cover first-turn creation, deduplication, retry of released failed/canceled/skipped runs, retry of a skipped run that never started, waiting for a failed run that hasn't released, source isolation, token-for-another-run rejection, endpoint acceptance, and continuation without a second run. The tests exercise real DB state and assert on outcomes, not just mocked calls.

Minor Observations (non-blocking)

  1. No test for the "queued" disposition. The queued path (turn coalesced into a not-yet-started run) is not directly tested. It's exercised indirectly through _continue_conversation_locked's coalescing logic, but a test that submits two turns for the same subject before the first run is dispatched would close the gap.

  2. Token signing key reuse. signing_secret() falls back to service_key (used for per-user API key minting) or local_api_key. The KV token uses a separate kv_secret. Key separation would be marginally better, but both secrets are server-side and never exposed to users, so the risk is theoretical. This follows the same pragmatic pattern as other service-level operations.

Risk Assessment

🟢 LOW — The change adds a new endpoint and table without modifying existing behavior. The dispatcher injection is additive (wrapped in try/except, silently disabled when no secret is configured). The security model is properly scoped. Race conditions are handled with advisory locks and unique constraints. No breaking changes to existing APIs.

Verdict

Worth merging — The design is clean, the security model is sound, and the retry/idempotency logic is correct. The minor testing gap for the "queued" disposition is non-blocking.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

@all-hands-bot
all-hands-bot dismissed their stale review September 15, 2026 01:00

Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.

@all-hands-bot

Copy link
Copy Markdown
Contributor

Successfully posted an APPROVE review to PR #468 (OpenHands/automation).

Review summary: The PR adds a scoped POST /v1/runs/{run_id}/subject-turns endpoint allowing running automation scripts to submit work for external subjects. The review found no critical issues:

  • Security model is sound — scoped JWT tokens bound to specific automation_id + run_id, proper authorization checks in the router
  • Idempotency and retry logic is correct — transaction-scoped advisory locks prevent races, unique constraint prevents duplicates, retry path properly releases failed runs before creating replacements
  • Migration is cross-database compatible — generic SQLAlchemy types, works on both PostgreSQL and SQLite
  • Test coverage is strong — 9 test functions covering authorization, idempotency, source isolation, continuation, and retryable terminal outcomes

Two minor non-blocking observations were noted in the review body: (1) no direct test for the "queued" disposition, and (2) token signing key reuse with service_key. Neither blocks merge.

Verdict: ✅ Worth merging — 🟢 LOW risk. Review submitted as APPROVED (Review ID: 5204429946).

This comment was posted by an AI agent (OpenHands).

@all-hands-bot

Copy link
Copy Markdown
Contributor

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

@neubig

neubig commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

@all-hands-bot The current head adds the requested queued-disposition coverage. Please review it and submit an APPROVE review if there are no blocking findings.

@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: 577c99fe107a2427adcb86720a50902adb07ed9e
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/d2566fa3-2062-41f0-b78d-5e5b3e426f0f

This comment was posted by an AI agent (OpenHands).

@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: 459f27fc8c39e2f287f6690152071c5d22ac6c35
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/2647e4a0-e987-4adf-9fac-3cc96b570d0b

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR adds a scoped POST /v1/runs/{run_id}/subject-turns endpoint that lets a running automation (scanner) submit conversation-scoped work for an external subject. The design is clean and well-structured: JWT-based run tokens with a single scope, a dedicated idempotency table with a unique constraint, and reuse of the subject locking/coalescing infrastructure from #467.

Taste Rating: 🟢 Good taste — the separation of concerns (scanner selects work, service owns conversation identity/runtime/serialization) is the right abstraction, and the idempotency + retry model handles the failure cases that matter.

Analysis

Authorization

The run token model is sound: HS256 JWT scoped to (automation_id, run_id, "subject_turn:submit") with 24h expiration. The router verifies token scope, run identity, automation identity, and RUNNING status before proceeding. The scanner receives no conversation credentials and cannot control conversation IDs. The signing_secret fallback (service_key or local_api_key) is acceptable — in cloud mode the service key is already the highest-privilege secret, so using it for JWT signing doesn't expand the attack surface, and the tokens themselves are low-privilege.

Idempotency and Retry Semantics

The automation_subject_turns table with its unique constraint on (automation_id, source, subject_key, idempotency_key) correctly prevents duplicate work. The retry logic is well-reasoned:

  • Queued/running/successful → deduplicated
  • Failed/cancelled/skipped + released → retried in the same deterministic conversation
  • Failed + not yet released → deduplicated (waits for release)

The handling of runs that never started (started_at is None) is particularly well done — explicitly setting subject_released_at before retrying to ensure _lock_subject_run excludes the superseded run.

Migration

Cross-database compatible: uses sa.Uuid, sa.String, sa.DateTime(timezone=True), sa.text("CURRENT_TIMESTAMP"). No PostgreSQL-specific types. Chain is correct (028 → 027).

Tests

Comprehensive coverage: first-turn creation, idempotency dedup, queued coalescing, retryable terminal outcomes, unreleased-runtime protection, source isolation, token authorization, and continuation without a second run. Tests exercise real code paths with a real database session.

Non-blocking observation

The PR description states "Retry failed, canceled, skipped, or timed-out work." Timed-out runs are marked FAILED by the watchdog, which does not set subject_released_at for conversation-scoped runs (the watchdog code has no subject-awareness — this is a pre-existing gap from the base branch, not introduced by this PR). Consequently, a submit_subject_turn retry with the same idempotency_key against a watchdog-timed-out run would deduplicate rather than retry, since can_retry requires subject_released_at is not None when started_at is set.

In practice this is mitigated: a new idempotency_key for the same subject goes through _continue_conversation_locked, which attempts delivery, fails (runtime gone), and releases the subject — unblocking subsequent retries. The live evidence in the PR description confirms retries work in the assembled stack. Flagging only for awareness; no change needed in this PR.

Risk Assessment

[RISK ASSESSMENT]

  • Overall PR: 🟢 LOW
    The endpoint is scoped, token-authenticated, and idempotent. No new external dependencies (pyjwt already declared). The migration is straightforward and cross-database compatible. The retry logic correctly handles the cases where subject_released_at is set, and the self-healing delivery path covers the gap for new idempotency keys.

VERDICT: ✅ Worth merging

KEY INSIGHT: The least-privilege token model — giving the scanner only subject-turn submission for its own run, never conversation credentials or ID control — is the right security boundary for fan-out from a scanner to multiple independent conversations.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

all-hands-bot
all-hands-bot previously approved these changes Sep 15, 2026

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR adds a scoped POST /v1/runs/{run_id}/subject-turns endpoint that lets a running scanner fan out conversation-scoped work for external subjects, with idempotency, coalescing, deterministic conversation identity, and retry of terminal outcomes. The design is clean: the service owns conversation identity and runtime lifecycle; the scanner receives only a short-lived, single-scope capability token.

Analysis

Idempotency and retry logicsubmit_subject_turn correctly serializes on the transaction-scoped subject lock before checking the idempotency record, so concurrent retries cannot create two conversations. The retry condition (status in _RETRYABLE and (started_at is None or subject_released_at is not None)) properly distinguishes between a run that never dispatched (safe to retry immediately) and one whose runtime must be released first. Reusing the existing idempotency record (retry_record.subject_run_id = subject_run.id) instead of inserting a new row avoids unique-constraint conflicts and preserves a single audit trail.

Capability token — The run token is scoped to subject_turn:submit, bound to a specific automation_id and run_id, and expires in 24h. The endpoint verifies both the scope and the run/automation binding before touching the database. The scanner never receives conversation credentials or control over conversation IDs. This is a sound least-privilege design.

Dispatcher refactor — The _release_conversation_subject closure consolidates subject release across all failure paths (concurrency limit, context fetch failure, conversation turn failure). The change to only release the subject after backend.release_context succeeds (line 431) is correct: if the runtime can't be released, marking the subject as released would orphan it.

Migration — Cross-database compatible: uses generic sa.Uuid() and sa.String() types, no PostgreSQL-specific features.

Non-blocking observation

The watchdog (watchdog.py) marks timed-out or crashed conversation-scoped runs as FAILED but does not set subject_released_at. When the dispatcher's own timeout handling fires (the effective_timeout passed to run_conversation_turn), the subject is released correctly. But if the dispatcher process itself crashes, the watchdog recovery path leaves subject_released_at NULL, and submit_subject_turn's retry check will return deduplicated instead of retrying. This is a narrow edge case (process crash during conversation turn execution), and the normal failure/timeout paths all release correctly. Consider having the watchdog set subject_released_at when it marks a conversation-scoped run terminal, as a follow-up.

Test coverage

Tests cover first-turn creation, idempotency deduplication, queued coalescing, retry of released FAILED/CANCELLED/SKIPPED runs, retry of never-started skipped runs, unreleased-runtime protection (deduplicated), source isolation, token-run binding (403), token automation binding, and continuation delivery. The dispatcher test for context-failure subject release is a good addition. Coverage is thorough.

[RISK ASSESSMENT]

  • Overall PR: 🟢 LOW. The change adds a new scoped endpoint with proper auth, locking, and idempotency. No existing behavior is altered — the dispatcher refactor preserves all existing release semantics. The signing secret gracefully degrades (no token injected if no secret configured).

VERDICT: ✅ Worth merging. Core logic is sound, idempotency and locking are correct, and the least-privilege token design is well-considered.

KEY INSIGHT: The subject lock + idempotency record combination ensures that retries are deterministic: one retry produces exactly one new run in the same derived conversation, never two.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

@neubig
neubig force-pushed the factory/subject-turns branch from 459f27f to a95124e Compare September 15, 2026 13:41
@all-hands-bot
all-hands-bot dismissed their stale review September 15, 2026 13:41

Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.

@neubig
neubig force-pushed the factory/subject-turns branch from a95124e to b841f7d Compare September 15, 2026 15:21
@neubig
neubig force-pushed the factory/subject-turns branch from b841f7d to bcd499c Compare September 15, 2026 15:44
@neubig
neubig force-pushed the factory/subject-turns branch from bcd499c to d91fce1 Compare September 15, 2026 15:48
@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: d91fce1c7fac438dd127e32b05af9bb082654ca7
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/bb1428bd-1a5c-464b-8f64-c4cd56aed6a2

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR adds a scoped POST /v1/runs/{run_id}/subject-turns endpoint that lets a running scanner fan out conversation-scoped work for external subjects. The design is sound: the service owns conversation identity and runtime lifecycle; the scanner receives only a short-lived, single-scope capability token. Idempotency, coalescing, deterministic conversation identity, and retry of terminal outcomes are all correctly implemented.

Material Finding

Subject stuck permanently when release_context fails after a conversation turn failure (dispatcher.py, line 434)

The refactored error handling gates subject_released_at on backend.release_context succeeding. If the conversation turn fails and release_context also fails -- which is the likely scenario when the sandbox/agent server is already dead or unreachable -- the subject is never released. The run is marked FAILED, but subject_released_at stays NULL.

This means submit_subject_turn's retry check (can_retry = status in _RETRYABLE and (started_at is None or subject_released_at is not None)) returns False, and the subject is permanently deduplicated -- the exact problem this PR set out to solve ("Permanently deduplicating failed, canceled, skipped, or timed-out work can leave an external issue or PR stuck forever").

The watchdog cannot recover this either: it only processes RUNNING runs, and this run is already FAILED. No code path sets subject_released_at for a FAILED run whose release_context failed.

The previous code unconditionally set subject_released_at before attempting release_context. The new ordering is a regression for this failure mode. Consider releasing the subject unconditionally when the run is being marked FAILED (as the pre-runtime failure paths at lines 365 and 388 already do), or at minimum releasing it when release_context fails for a run that will be terminal anyway -- the runtime is effectively gone in that case.

[RISK ASSESSMENT]

  • Overall PR: 🟡 MEDIUM. The idempotency, locking, and auth design are solid. The one material concern is the subject-stuck-forever path when release_context fails, which contradicts the PR's retry goal and requires operator intervention to recover.

VERDICT: Worth merging with a follow-up to address the release_context failure path -- or a quick fix to release the subject unconditionally on terminal failure, matching the pre-runtime failure paths.

KEY INSIGHT: The subject lock + idempotency record combination ensures deterministic retries, but only if subject_released_at is set on every terminal failure path -- the release_context-failed path is the gap.

Comment thread openhands/automation/dispatcher.py Outdated
)
await release_session.commit()
await backend.release_context(client, ctx)
if runtime_released:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The runtime_released gate means subject_released_at is never set when backend.release_context fails. This is the likely failure mode when the sandbox/agent server is already dead -- the turn fails, then release fails for the same reason.

The run is marked FAILED by _fail, but no other code path sets subject_released_at for a FAILED run. The watchdog only processes RUNNING runs. So submit_subject_turn's retry check returns False (started_at is not None and subject_released_at is None), and the subject is permanently deduplicated -- the exact scenario this PR aims to fix.

The pre-runtime failure paths (lines 365, 388) correctly call _release_conversation_subject unconditionally. This path should do the same, or at least release when the run is about to be marked terminal -- the runtime is effectively gone in that case.

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@neubig
neubig force-pushed the factory/subject-turns branch from d91fce1 to 533d927 Compare September 15, 2026 16:20
@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: 533d9274ab8f43698a501a1c548f1a1336fe1038
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/a71eee71-f821-45f1-9a00-b97c6cf27365

This comment was posted by an AI agent (OpenHands).

all-hands-bot
all-hands-bot previously approved these changes Sep 15, 2026

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

What changed since the last review

The latest commit (533d9274, "fix: unblock failed subject retries") directly addresses the material finding from the prior review on d91fce1c: the subject was permanently stuck when both the conversation turn failed and backend.release_context also failed, because subject_released_at was gated on release_context succeeding.

Analysis of the fix

The conversation-turn failure path in dispatcher.py (lines 424-442) now wraps backend.release_context in its own try/except and calls _release_conversation_subject() unconditionally afterward - regardless of whether the runtime release succeeded or failed. This is the correct behavior: a run being marked FAILED has already lost its runtime, so the subject must be released to allow idempotent retries.

This matches the pre-runtime failure paths (lines 365-366, 387-388), which already release the subject unconditionally. All terminal failure paths for conversation-scoped runs now set subject_released_at.

The new test test_conversation_turn_failure_releases_subject_when_cleanup_fails (test_dispatcher.py, lines 307-370) verifies the exact scenario: submit_conversation_turn raises, release_context raises, and the test asserts finished.subject_released_at is not None. The existing test_conversation_turn_context_failure_releases_subject covers the pre-runtime path.

Full PR review

Security model - Sound. The run token is a short-lived (24h) HS256 JWT scoped to subject_turn:submit, bound to automation_id + run_id. The router verifies scope, run identity, automation identity, and RUNNING status before proceeding. The scanner receives neither conversation credentials nor control over conversation IDs.

Idempotency and retry - Correct. The transaction-scoped subject lock serializes the SELECT-then-INSERT idempotency check. The unique constraint on (automation_id, source, subject_key, idempotency_key) prevents duplicates at the DB level. The retry condition properly distinguishes never-started runs (releases immediately) from started-but-terminal runs (requires subject_released_at). The idempotency record is repointed to the new attempt rather than inserting a new row.

Migration - Cross-database compatible: generic sa.Uuid(), sa.String(), sa.DateTime(timezone=True). Chain 027 -> 028 intact.

Test coverage - Comprehensive: 12 tests covering first-turn creation, idempotency dedup, queued coalescing, retry of released FAILED/CANCELLED/SKIPPED runs, retry of never-started skipped runs, unreleased-runtime protection, source isolation, token authorization (403 for wrong run, 202 for correct), continuation delivery, pre-runtime context-failure subject release, and cleanup-failure subject release. All tests exercise real DB state.

Risk Assessment

[RISK ASSESSMENT]

  • Overall PR: 🟢 LOW
    The endpoint is scoped, token-authenticated, and idempotent. The dispatcher change fixes a real failure mode (subject stuck when runtime cleanup fails) with matching test coverage. No existing behavior is altered - the dispatcher refactor preserves all existing release semantics while closing the gap. The signing secret gracefully degrades (no token injected if no secret configured). No new external dependencies (PyJWT already declared).

VERDICT: ✅ Worth merging. The previous material finding is resolved, and no new issues were found.

KEY INSIGHT: The unconditional subject_released_at on every terminal failure path - pre-runtime, runtime-gone, and cleanup-failed - ensures that the retry condition in submit_subject_turn can never be permanently blocked by a failed runtime release.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your own agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

@all-hands-bot
all-hands-bot dismissed their stale review September 15, 2026 16:39

Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.

@all-hands-bot

Copy link
Copy Markdown
Contributor

Successfully posted an APPROVED review to PR #468 on OpenHands/automation.

Summary: The latest commit (533d9274, "fix: unblock failed subject retries") directly addresses the material finding from the prior review — the subject was permanently stuck when both the conversation turn failed and release_context also failed. The fix wraps release_context in try/except and calls _release_conversation_subject() unconditionally afterward, ensuring subject_released_at is set on every terminal failure path. The new test test_conversation_turn_failure_releases_subject_when_cleanup_fails verifies this exact scenario.

Verdict: ✅ Worth merging, 🟢 LOW risk. No new material findings. The security model (scoped JWT), idempotency logic (advisory lock + unique constraint), and cross-database migration are all sound. Per the custom codereview guide, the review was submitted as APPROVED since the verdict is "Worth merging" with no blocking issues.

Review URL: #468 (review)

This comment was posted by an AI agent (OpenHands).

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: retry unsuccessful idempotent subject turns Add service-owned subject turns for agent-backed automations

3 participants